spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1import { Download, ExternalLink } from 'lucide-react';2import type { Metadata } from 'next';3import Link from 'next/link';4import { notFound } from 'next/navigation';5import { t, tOpt } from '@/i18n';6import { api, isNotBuilt, isNotFound, safe } from '@/lib/api';7import { apiAnalytics } from '@/lib/api-analytics';8import { apiExplore } from '@/lib/api-explore';9import { formatDate, formatPct, formatValue, grouped, isNum } from '@/lib/format';10import { jsonLd, jsonLdString, seoTitle } from '@/lib/seo';11import { SITE_URL, routes } from '@/lib/site';12import { topicById } from '@/lib/topics';13import type { CountrySummary, FormatSpec, Series } from '@/lib/types';14import type { FramesResponse } from '@/lib/types-analytics';15import type { IndicatorResponse, RankedValue } from '@/lib/types-explore';16import { RankRace } from '@/components/charts/rank-race';17import { RankedBars, rankedRowFromCountry, type RankedBarRow } from '@/components/charts/ranked-bars';18import { EmptyState, NotBuiltState } from '@/components/data/empty-state';19import type { ProvenancePayload } from '@/components/data/provenance-context';20import { QualityBadges } from '@/components/data/quality-badge';21import { Section } from '@/components/data/section';22import { CodeBlock } from '@/components/explore/copy-button';23import { ACTION_CLS, PageHeader } from '@/components/explore/page-header';24import { DistributionPanel } from '@/components/indicators/distribution-panel';25import { IndicatorCompare, type CompareCountry } from '@/components/indicators/indicator-compare';26import { IndicatorMap } from '@/components/indicators/indicator-map';27import { IndicatorTrend } from '@/components/indicators/indicator-trend';28import { baseFeatures } from '@/components/indicators/map-geometry';29import { RelatedTable } from '@/components/indicators/related-table';3031export const revalidate = 900;3233type Params = { slug: string };34type SP = Record<string, string | string[] | undefined>;3536async function load(slug: string): Promise<IndicatorResponse | 'not-built' | null> {37 try {38 return await apiExplore.indicator(slug);39 } catch (e) {40 if (isNotFound(e)) return null;41 if (isNotBuilt(e)) return 'not-built';42 throw e;43 }44}4546export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {47 const { slug } = await params;48 const data = await load(slug);49 if (!data || data === 'not-built') return { title: t('indicator.notFound'), robots: { index: false } };50 const ind = data.indicator;51 const name = titleCase(ind.short_name ?? ind.name ?? slug);52 const title = seoTitle.indicator(name);53 const description = t('indicator.description', { name: ind.name ?? name, unit: ind.unit ?? '', n: data.coverage.n_countries ?? 0, y0: data.years.first ?? '', y1: data.years.last_actual ?? data.years.last ?? '' });54 const canonical = routes.indicator(ind.slug);55 return {56 title: { absolute: `${title} | ${t('site.name')}` },57 description,58 alternates: { canonical },59 openGraph: { title: `${title} | ${t('site.name')}`, description, url: canonical, type: 'article' },60 twitter: { card: 'summary_large_image', title, description },61 };62}6364/** "Life expectancy" → "Life Expectancy"; keeps acronyms (GDP, CO₂) and short function words lower-case. */65function titleCase(s: string): string {66 const small = new Set(['of', 'per', 'to', 'in', 'at', 'by', 'and', 'or', 'the', 'a', 'an', 'vs', 'on', 'for', 'as']);67 return s68 .split(' ')69 .map((w, i) => (i > 0 && small.has(w.toLowerCase()) ? w.toLowerCase() : w === w.toUpperCase() ? w : w.charAt(0).toUpperCase() + w.slice(1)))70 .join(' ');71}7273function specOf(ind: IndicatorResponse['indicator']): FormatSpec {74 return { format: ind.format, unit: ind.unit, unit_short: ind.unit_short, precision: ind.precision, frequency: ind.frequency, name: ind.short_name ?? ind.name, higher_is_better: ind.higher_is_better };75}7677/** 10-year movers from the frames payload: change between the latest frame and the frame ten years earlier (± 2). */78function decadeMovers(frames: FramesResponse | null, countries: Map<string, CountrySummary>, hib: boolean | null | undefined, relative: boolean): { up: RankedBarRow[]; down: RankedBarRow[]; from: number; to: number } | null {79 if (!frames || frames.years.length < 11) return null;80 const years = frames.years;81 const toIdx = years.length - 1;82 const to = years[toIdx]!;83 let fromIdx = years.findIndex((y) => y >= to - 10);84 if (fromIdx < 0 || fromIdx === toIdx) return null;85 if (years[fromIdx]! > to - 8) fromIdx = Math.max(0, fromIdx - 1);86 const from = years[fromIdx]!;87 const rows: Array<{ id: string; delta: number; v0: number; v1: number }> = [];88 for (const [iso, arr] of Object.entries(frames.values)) {89 const v1 = arr[toIdx];90 const v0 = arr[fromIdx];91 if (typeof v1 !== 'number' || typeof v0 !== 'number') continue;92 const pop = countries.get(iso)?.population_latest ?? 0;93 if (pop < 1_000_000) continue;94 const delta = relative ? (v0 !== 0 ? ((v1 - v0) / Math.abs(v0)) * 100 : NaN) : v1 - v0;95 if (!Number.isFinite(delta)) continue;96 rows.push({ id: iso, delta, v0, v1 });97 }98 if (rows.length < 10) return null;99 const sorted = [...rows].sort((a, b) => b.delta - a.delta);100 const mk = (r: { id: string; delta: number; v0: number; v1: number }, i: number): RankedBarRow => {101 const c = countries.get(r.id);102 return { id: r.id, label: c?.name ?? r.id, flag: c?.flag ?? null, href: c?.slug ? routes.country(c.slug) : null, value: r.delta, rank: i + 1 };103 };104 const up = sorted.slice(0, 6).map(mk);105 const down = sorted.slice(-6).reverse().map(mk);106 // Direction semantics: when higher is better, "up" is the improvement list; when lower is better, swap.107 return { up: hib === false ? down : up, down: hib === false ? up : down, from, to };108}109110export default async function IndicatorPage({ params, searchParams }: { params: Promise<Params>; searchParams: Promise<SP> }) {111 const [{ slug }, sp] = await Promise.all([params, searchParams]);112 const data = await load(slug);113 if (data === null) notFound();114 if (data === 'not-built') return <NotBuiltState />;115116 const ind = data.indicator;117 const name = ind.name ?? ind.slug;118 const spec = specOf(ind);119 const yearUsed = data.years.latest_common ?? data.world_latest?.year ?? data.years.last_actual ?? null;120 const highlight = typeof sp.country === 'string' && /^[a-z0-9-]+$/i.test(sp.country) ? sp.country : null;121122 const [countriesRes, frames, trend, related, quality, distribution, race] = await Promise.all([123 safe(api.countries()),124 safe(apiAnalytics.indicatorFrames(ind.slug)),125 safe(apiExplore.indicatorTrend(ind.slug, 'world')),126 safe(apiAnalytics.indicatorRelated(ind.slug, { limit: 10 })),127 safe(apiAnalytics.indicatorQuality(ind.slug)),128 safe(apiAnalytics.indicatorDistribution(ind.slug, { highlight })),129 ind.ranking_eligible !== false ? safe(apiAnalytics.race(ind.slug, { top: 10 })) : Promise.resolve(null),130 ]);131 const countries = countriesRes?.items ?? [];132 const onlyCountries = countries.filter((c) => (c.kind ?? 'country') === 'country');133 const byId = new Map(countries.map((c) => [c.id, c]));134135 // Default comparison: the 3 largest economies that have data for this indicator.136 const lastIdx = frames ? frames.years.length - 1 : -1;137 const hasValue = (id: string) => (frames && lastIdx >= 0 ? typeof frames.values[id]?.[lastIdx] === 'number' : false);138 const defaults: CompareCountry[] = [...onlyCountries]139 .filter((c) => isNum(c.gdp_latest) && hasValue(c.id))140 .sort((a, b) => (b.gdp_latest ?? 0) - (a.gdp_latest ?? 0))141 .slice(0, 3)142 .map((c) => ({ id: c.id, slug: c.slug ?? c.id, name: c.name ?? c.id, flag: c.flag }));143 const bundle = defaults.length ? await safe(apiExplore.seriesBundle(defaults.map((c) => c.id), [ind.slug])) : null;144 const initialSeries: Series[] = bundle?.series ?? [];145 const { features, sphere } = countries.length ? baseFeatures(countries) : { features: [], sphere: '' };146147 const wl = data.world_latest;148 const worldPayload: ProvenancePayload = {149 indicator: { slug: ind.slug, name, format: ind.format, unit: ind.unit, unit_short: ind.unit_short, frequency: ind.frequency, higher_is_better: ind.higher_is_better, methodology: ind.methodology, description: ind.description },150 value: wl ? { value: wl.value, formatted: wl.formatted, period: wl.year ? `${wl.year}-01-01` : null, year: wl.year, unit: ind.unit, provenance: frames?.provenance ?? data.top5[0]?.provenance ?? null } : null,151 country: null,152 downloadHref: routes.indicatorDownload(ind.slug),153 };154155 const hib = ind.higher_is_better;156 const topLabel = hib == null ? t('indicator.top.highest') : t('indicator.top.best');157 const bottomLabel = hib == null ? t('indicator.top.lowest') : t('indicator.top.worst');158 const toRows = (rows: RankedValue[]) => rows.map((r) => rankedRowFromCountry(r.country, r.value, r.rank));159 const relative = ['currency', 'number', 'tonnes', 'kwh'].includes(ind.format ?? '');160 const movers = decadeMovers(frames, byId, hib, relative);161 const deltaSpec: FormatSpec = relative ? { format: 'percent', precision: 0, unit: '%' } : { format: ind.format === 'percent' || ind.format === 'years' || ind.format === 'index' || ind.format === 'ratio' ? ind.format : ind.format, unit: ind.unit, unit_short: ind.unit_short, precision: ind.precision };162163 const missing: CountrySummary[] = frames && lastIdx >= 0 ? onlyCountries.filter((c) => !hasValue(c.id)).sort((a, b) => (b.population_latest ?? 0) - (a.population_latest ?? 0)) : [];164 const topic = topicById(ind.topic ?? '');165 const apiSnippet = `curl -s "${SITE_URL}/api/v1/series?country=CAN&indicator=${ind.slug}" | jq '.series[0] | {country: .country.name, unit, last: .stats.last, source: .provenance.source_name}'`;166 const freqLabel = tOpt(`indicator.frequency.${ind.frequency ?? 'A'}`, ind.frequency ?? 'A');167 const worldKindLabel = wl ? tOpt(`indicator.world.${wl.kind}`, t('indicator.world', { kind: wl.kind })) : null;168 const ld = [169 jsonLd.dataset({ slug: ind.slug, name: ind.name ?? name, description: ind.description, unit: ind.unit, firstYear: data.years.first, lastYear: data.years.last_actual, nCountries: data.coverage.n_countries, sources: data.sources.map((s) => ({ name: s.source_name, url: s.url, licence: s.licence })), modified: data.meta.built_at }),170 jsonLd.breadcrumbs([{ name: t('indicators.title'), path: routes.indicators() }, ...(topic ? [{ name: topic.short, path: routes.indicators(topic.id) }] : []), { name: ind.short_name ?? name, path: routes.indicator(ind.slug) }]),171 ];172173 return (174 <>175 <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdString(ld) }} />176 <PageHeader177 crumbs={[{ href: routes.indicators(), label: t('indicators.title') }, ...(topic ? [{ href: routes.indicators(topic.id), label: topic.short }] : [])]}178 eyebrow={[topic?.name, ind.subtopic].filter(Boolean).join(' · ')}179 title={name}180 lede={ind.description}181 meta={182 <span className="flex flex-wrap items-center gap-x-2 gap-y-1">183 <span>184 {ind.unit} · {freqLabel} · {t('indicator.coverage', { n: grouped(data.coverage.n_countries ?? 0), total: grouped(data.coverage.n_countries_total), pct: formatPct(data.coverage.coverage_pct) })}185 {data.years.first && data.years.last_actual ? ` · ${data.years.first}–${data.years.last_actual}` : ''}186 {data.sources[0]?.source_name ? ` · ${data.sources[0].source_name}` : ''}187 </span>188 <QualityBadges badges={quality?.badges ?? null} />189 </span>190 }191 actions={192 <>193 {ind.ranking_eligible !== false ? (194 <Link href={routes.ranking(ind.slug)} className={ACTION_CLS}>195 {t('common.ranking')}196 </Link>197 ) : null}198 <Link href={routes.explore({ indicator: ind.slug })} className={ACTION_CLS}>199 {t('nav.explore')}200 </Link>201 <a href={routes.indicatorDownload(ind.slug)} className={ACTION_CLS}>202 <Download size={14} aria-hidden /> {t('common.downloadCsv')}203 </a>204 </>205 }206 />207208 {/* World figure + freshness */}209 <section aria-label={t('indicator.freshness')} className="border-y border-rule">210 <dl className="grid grid-cols-2 gap-y-4 py-4 sm:grid-cols-4 lg:divide-x lg:divide-rule">211 <div className="col-span-2 sm:col-span-1 lg:pr-4">212 <dt className="eyebrow">{worldKindLabel ?? t('indicator.world.median')}</dt>213 <dd className="pnum mt-1 text-2xl font-semibold leading-none text-ink md:text-3xl">{formatValue(wl?.value, spec)}</dd>214 {wl?.year ? <dd className="tnum mt-1 text-xs text-ink-3">{t('indicator.world.n', { n: grouped(wl.n ?? 0), year: wl.year })}</dd> : null}215 </div>216 <div className="lg:px-4">217 <dt className="eyebrow">{t('indicator.freshness.latest')}</dt>218 <dd className="tnum mt-1 text-base font-semibold text-ink">{data.years.last_actual ?? t('common.na')}</dd>219 {data.years.last && data.years.last_actual && data.years.last > data.years.last_actual ? <dd className="text-2xs text-ink-3">{t('common.forecast')} → {data.years.last}</dd> : null}220 {quality ? <dd className="tnum text-2xs text-ink-3">{t('indicator.quality.years', { n: quality.years_with_50plus })}</dd> : null}221 </div>222 <div className="lg:px-4">223 <dt className="eyebrow">{t('indicator.freshness.source')}</dt>224 <dd className="tnum mt-1 text-base font-semibold text-ink">{formatDate(data.freshness.source_updated_at)}</dd>225 {quality ? <dd className="tnum text-2xs text-ink-3">{t('indicator.quality.flagged', { n: grouped(quality.flagged_values) })}</dd> : null}226 </div>227 <div className="lg:pl-4">228 <dt className="eyebrow">{t('indicator.freshness.refreshed')}</dt>229 <dd className="tnum mt-1 text-base font-semibold text-ink">{formatDate(data.freshness.retrieved_at ?? data.freshness.built_at)}</dd>230 {data.meta.run_id ? <dd className="text-2xs text-ink-3">{t('site.footer.build', { run: data.meta.run_id })}</dd> : null}231 </div>232 </dl>233 </section>234235 <Section id="map" title={t('indicator.map.title')} subtitle={t('indicator.map.sub')} className="border-t-0">236 {features.length ? <IndicatorMap slug={ind.slug} geometry={features} sphere={sphere} frames={frames} spec={spec} payload={worldPayload} initialYear={yearUsed} /> : <EmptyState title={t('common.noDataLong')} />}237 </Section>238239 <Section id="trend" title={t('indicator.trend.title')} subtitle={t('indicator.trend.sub')}>240 <IndicatorTrend slug={ind.slug} initial={trend} spec={spec} payload={worldPayload} />241 </Section>242243 <Section id="top" title={t('indicator.top.title')} subtitle={yearUsed ? t('indicator.top.sub', { year: yearUsed }) : undefined} actions={ind.ranking_eligible !== false ? <Link href={routes.ranking(ind.slug)} className="text-accent hover:underline">{t('indicator.top.fullRanking')} →</Link> : null}>244 {data.top5.length === 0 ? (245 <EmptyState compact title={t('common.noDataLong')} />246 ) : (247 <div className="grid gap-x-10 gap-y-6 lg:grid-cols-2">248 <div>249 <h3 className="mb-2 text-sm font-semibold text-ink">{topLabel}</h3>250 <RankedBars rows={toRows(data.top5)} spec={spec} provenance={data.top5[0]?.provenance ?? null} />251 </div>252 {data.bottom5.length ? (253 <div>254 <h3 className="mb-2 text-sm font-semibold text-ink">{bottomLabel}</h3>255 <RankedBars rows={toRows(data.bottom5)} spec={spec} provenance={data.bottom5[0]?.provenance ?? null} />256 </div>257 ) : null}258 </div>259 )}260 </Section>261262 {movers ? (263 <Section id="movers" title={t('indicator.movers.title', { y0: movers.from, y1: movers.to })} subtitle={hib == null ? t('indicator.movers.subNeutral') : t('indicator.movers.sub')}>264 <div className="grid gap-x-10 gap-y-6 lg:grid-cols-2">265 <div>266 <h3 className="mb-2 flex items-center gap-1.5 text-sm font-semibold text-ink">267 <span aria-hidden className={hib == null ? 'text-inc' : 'text-up'}>↑</span> {hib == null ? t('indicator.movers.largestIncrease') : t('indicator.movers.fastestImproving')}268 </h3>269 <RankedBars rows={movers.up} spec={deltaSpec} showRank={false} />270 </div>271 <div>272 <h3 className="mb-2 flex items-center gap-1.5 text-sm font-semibold text-ink">273 <span aria-hidden className={hib == null ? 'text-dec' : 'text-down'}>↓</span> {hib == null ? t('indicator.movers.largestDecrease') : t('indicator.movers.fastestDeclining')}274 </h3>275 <RankedBars rows={movers.down} spec={deltaSpec} showRank={false} />276 </div>277 </div>278 <p className="mt-3 text-xs text-ink-3">{t('indicator.movers.note', { y0: movers.from, y1: movers.to, kind: relative ? t('indicator.movers.relative') : t('indicator.movers.absolute') })}</p>279 </Section>280 ) : null}281282 {distribution && distribution.n >= 10 ? (283 <Section id="distribution" title={t('indicator.dist.section')} subtitle={t('indicator.dist.sectionSub')}>284 <DistributionPanel data={distribution} spec={spec} />285 </Section>286 ) : null}287288 {race && race.frames.length >= 20 ? (289 <Section id="race" title={t('indicator.race.title')} subtitle={t('ranking.race.sub', { name: spec.name ?? name, y0: race.years[0] ?? '', y1: race.years[race.years.length - 1] ?? '', top: race.top })}>290 <RankRace data={race} spec={spec} top={10} initialYear={yearUsed} />291 </Section>292 ) : null}293294 <Section id="compare" title={t('indicator.compare.title')} subtitle={t('indicator.compare.sub')}>295 <IndicatorCompare slug={ind.slug} spec={spec} initialCountries={defaults} initialSeries={initialSeries} payload={worldPayload} />296 </Section>297298 <Section id="related" title={t('indicator.related.statTitle')} subtitle={t('indicator.related.statSub')} actions={<Link href={routes.scatter({ x: ind.slug })} className="text-accent hover:underline">{t('indicator.related.openScatter')} →</Link>}>299 {related ? <RelatedTable data={related} slug={ind.slug} /> : <p className="text-sm text-ink-3">{t('indicator.related.noneStat')}</p>}300 </Section>301302 <Section id="definition" title={t('indicator.definition')} level={2}>303 <dl className="grid gap-x-8 gap-y-3 text-sm sm:grid-cols-2 lg:grid-cols-3">304 <div>305 <dt className="eyebrow">{t('indicator.definition')}</dt>306 <dd className="mt-0.5 text-ink-2">{ind.description ?? t('common.na')}</dd>307 </div>308 {ind.methodology ? (309 <div>310 <dt className="eyebrow">{t('indicator.methodology')}</dt>311 <dd className="mt-0.5 text-ink-2">{ind.methodology}</dd>312 </div>313 ) : null}314 <div>315 <dt className="eyebrow">{t('indicator.unit')}</dt>316 <dd className="mt-0.5 text-ink">317 {ind.unit ?? t('common.na')}318 {ind.unit_short && ind.unit_short !== ind.unit ? <span className="text-ink-3"> ({ind.unit_short})</span> : null}319 </dd>320 </div>321 <div>322 <dt className="eyebrow">{t('indicator.frequency')}</dt>323 <dd className="mt-0.5 text-ink">{freqLabel}</dd>324 </div>325 <div>326 <dt className="eyebrow">{t('indicator.aggregation')}</dt>327 <dd className="mt-0.5 text-ink">328 {ind.aggregation ?? 'none'} · {t(`indicator.higherIsBetter.${hib == null ? 'null' : hib ? 'true' : 'false'}` as 'indicator.higherIsBetter.null')}329 </dd>330 </div>331 {ind.bounds && (ind.bounds[0] != null || ind.bounds[1] != null) ? (332 <div>333 <dt className="eyebrow">{t('indicator.bounds')}</dt>334 <dd className="tnum mt-0.5 text-ink">335 {ind.bounds[0] ?? '−∞'} – {ind.bounds[1] ?? '∞'}336 </dd>337 </div>338 ) : null}339 </dl>340 </Section>341342 <Section id="sources" title={t('indicator.sources.title')} subtitle={t('indicator.sources.sub')}>343 <ol className="divide-y divide-rule border-y border-rule">344 {data.sources.map((s) => (345 <li key={`${s.source_id}-${s.series_code}`} className="grid gap-x-6 gap-y-1 py-3 sm:grid-cols-[2.5rem_minmax(0,1fr)_auto] sm:items-start">346 <span className="tnum text-xs text-ink-3">347 <span className="sr-only">{t('indicator.sources.priority')} </span>#{s.priority ?? '—'}348 </span>349 <div className="min-w-0 text-sm">350 <div className="flex flex-wrap items-baseline gap-x-2">351 <Link href={routes.source(s.source_id)} className="link-quiet inline-flex min-h-[44px] items-center font-medium text-ink md:min-h-0">352 {s.source_name ?? s.source_id}353 </Link>354 <span className="text-ink-3">{s.dataset}</span>355 <code className="break-all rounded-xs bg-surface-2 px-1 font-mono text-xs text-ink-2">{s.series_code}</code>356 {s.last_status ? <span className="text-2xs uppercase tracking-wide text-ink-3">{s.last_status}</span> : null}357 </div>358 <div className="tnum mt-0.5 text-xs text-ink-3">359 {s.n_countries != null ? t('indicators.coverageShort', { n: grouped(s.n_countries) }) : ''}360 {s.n_observations != null ? ` · ${grouped(s.n_observations)} obs.` : ''}361 {s.last_year ? ` · → ${s.last_year}` : ''}362 {s.licence ? ` · ${s.licence}` : ''}363 {s.transform ? ` · ${t('indicator.sources.transform', { expr: s.transform })}` : ''}364 {s.countries?.length ? ` · ${t('indicator.sources.restricted', { n: s.countries.length })}` : ''}365 </div>366 {s.notes ? <p className="mt-1 line-clamp-2 text-xs text-ink-3">{s.notes}</p> : null}367 </div>368 {s.url ? (369 <a href={s.url} target="_blank" rel="noopener noreferrer" className="inline-flex min-h-[44px] items-center gap-1 text-xs text-accent hover:underline md:min-h-[32px]">370 <ExternalLink size={12} aria-hidden /> {t('indicator.sources.open')}371 </a>372 ) : null}373 </li>374 ))}375 </ol>376 <p className="mt-3 max-w-prose text-xs leading-relaxed text-ink-3">{t('indicator.sources.rule')}</p>377 </Section>378379 <div className="grid gap-x-10 lg:grid-cols-2">380 <Section id="no-data" title={t('indicator.nodata.title')} subtitle={frames && yearUsed ? (missing.length ? t('indicator.nodata.sub', { n: grouped(missing.length), total: grouped(onlyCountries.length), year: frames.years[lastIdx] ?? yearUsed }) : t('indicator.nodata.none', { year: frames.years[lastIdx] ?? yearUsed })) : undefined}>381 {missing.length ? (382 <>383 <ul className="flex flex-wrap gap-1.5 text-xs">384 {missing.slice(0, 24).map((c) => (385 <li key={c.id}>386 <Link href={routes.country(c.slug ?? c.id)} className="inline-flex min-h-[32px] items-center gap-1 rounded-sm border border-rule px-2 text-ink-2 hover:border-accent hover:text-accent">387 <span aria-hidden>{c.flag}</span> {c.name}388 </Link>389 </li>390 ))}391 {missing.length > 24 ? <li className="inline-flex min-h-[32px] items-center px-1 text-ink-3">{t('indicator.nodata.more', { n: missing.length - 24 })}</li> : null}392 </ul>393 <p className="mt-3 max-w-prose text-xs leading-relaxed text-ink-3">{t('indicator.nodata.why')}</p>394 </>395 ) : (396 <p className="text-sm text-ink-2">{t('indicator.nodata.why')}</p>397 )}398 </Section>399400 <Section id="downloads" title={t('indicator.downloads.title')} subtitle={t('indicator.downloads.sub')}>401 <div className="flex flex-wrap gap-2">402 <a href={routes.indicatorDownload(ind.slug, 'csv')} className={ACTION_CLS}>403 <Download size={14} aria-hidden /> {t('indicator.downloads.csv')}404 </a>405 <a href={routes.indicatorDownload(ind.slug, 'json')} className={ACTION_CLS}>406 <Download size={14} aria-hidden /> {t('indicator.downloads.json')}407 </a>408 <Link href={routes.download({ indicators: ind.slug })} className={ACTION_CLS}>409 {t('indicator.downloads.builder')}410 </Link>411 </div>412 <h3 className="mt-5 text-sm font-semibold text-ink">{t('indicator.downloads.api')}</h3>413 <p className="mb-2 text-xs text-ink-3">{t('indicator.downloads.apiHint')}</p>414 <CodeBlock code={apiSnippet} lang="bash" />415 <p className="mt-2 text-xs text-ink-3">416 <Link href={routes.api()} className="text-accent hover:underline">417 {t('data.api.link')} →418 </Link>419 </p>420 </Section>421 </div>422 </>423 );424}425